Skip to content

feat(scripts): enforce job-level permissions in the workflow permissions gate - #2553

Merged
WilliamBerryiii merged 8 commits into
mainfrom
feat/job-level-permissions-lint
Jul 31, 2026
Merged

feat(scripts): enforce job-level permissions in the workflow permissions gate#2553
WilliamBerryiii merged 8 commits into
mainfrom
feat/job-level-permissions-lint

Conversation

@WilliamBerryiii

@WilliamBerryiii WilliamBerryiii commented Jul 29, 2026

Copy link
Copy Markdown
Member

Description

scripts/security/Test-WorkflowPermissions.ps1 checked one thing: whether a file contained a top-level permissions: block, matched with (?m)^permissions: against raw text. It had no job awareness at all. It scored this repository 100% (64/64) while jobs across six workflows carried no declaration of their own.

The convention in .github/instructions/workflows.instructions.md requires job-level declarations, and docs/security/security-model.md asserted that all workflows already had them. Neither was enforced, and the second was not true.

This change taught the gate to parse each workflow with ConvertFrom-Yaml, enumerate its jobs from the parsed object graph, and apply a four-state classification.

Workflow-level block Job-level block Effective scopes Verdict
absent absent repository or organization default violation
permissions: {} absent none pass
populated absent inherits the workflow grant violation
any present job-declared pass

Why row 3 is the one that matters

A measurement of all 65 workflows found 0 absent, 7 empty, 58 populated top-level blocks. A rule built only on row 1 would have been unreachable ΓÇö it would have detected the empty set and shipped as a no-op. Row 3 gives the gate a reachable failure state, and it found exactly one on this tree: .github/workflows/beval.yml job evaluate.

That job now declares contents: read ΓÇö which it already inherited. The effective permission set is identical before and after. No job's privilege was widened anywhere in this change, and no generated gh-aw lock file was edited.

Why row 2 is a deliberate pass

An empty workflow-level block grants nothing, so a job beneath it that declares no block inherits an empty set and holds no scope. This is precisely the distinction the third-party Poutine scanner cannot make ΓÇö its utils.empty() helper treats an empty map identically to an absent one ΓÇö which is why it raised code scanning alert #348 against .github/workflows/pr-review.lock.yml, a file that implements Poutine's own documented remediation. That alert was dismissed as a false positive. This change adds no .poutine.yml suppression; a precise local gate was preferred over silencing a scanner.

Worth stating plainly: an empty workflow-level block is a default, not a ceiling. A job that declares its own block beneath one still receives what it declares, because job-level permissions replace the workflow-level set rather than being capped by it.

Supporting changes

  • SecurityClasses.psm1 gains a MissingJobPermissions value in the ViolationType ValidateSet, which would otherwise throw on the first job-level finding. Additive only.
  • File-level and job-level compliance are reported as separate metrics so compliance-score and violation-count cannot contradict each other under a mixed model.
  • SARIF output declares a rule per violation type, emits a matching ruleId, floors startLine at 1, and normalizes paths to forward slashes. The previous version hardcoded a single ruleId and startLine = 1; since the upload step is continue-on-error, a malformed document would have failed silently.
  • workflow-permissions-scan.yml gains the setup-ps-modules step. The script now depends on PowerShell-Yaml, which is not preinstalled on ubuntu-latest.

Reviewer note on compliance-score

The score is now denominated in workflow-level plus job-level checks ΓÇö 257 checks on this tree instead of 64 files. It remains a 0ΓÇô100 percentage, so the downstream contract shape in workflow-permissions-scan.yml and pr-validation.yml is unchanged, but historical scores are not directly comparable.

Two job metrics are published deliberately: 193/193 jobs pass the job-level check, but only 188/193 declare their own block. The remaining five pass by inheriting an empty workflow-level block. Collapsing these into one number would have claimed every job declares permissions, which is false.

Related Issue(s)

Closes #2541

Split out from #2527, which covers the full Poutine scan of main and remains open independently.

Type of Change

Select all that apply:

Code & Documentation:

  • Bug fix (non-breaking change fixing an issue)
  • New feature (non-breaking change adding functionality)
  • Breaking change (fix or feature causing existing functionality to change)
  • Documentation update

Infrastructure & Configuration:

  • GitHub Actions workflow
  • Linting configuration (markdown, PowerShell, etc.)
  • Security configuration
  • DevContainer configuration
  • Dependency update

AI Artifacts:

  • Reviewed contribution with hve-builder and addressed all actionable findings
  • Copilot instructions (.github/instructions/*.instructions.md)
  • Copilot prompt (.github/prompts/*.prompt.md)
  • Copilot agent (.github/agents/*.agent.md)
  • Copilot skill (.github/skills/*/SKILL.md)
  • Copilot hook (.github/hooks/*/*.json)
  • Eval spec added/updated for changed AI artifacts (evals/)

Note for AI Artifact Contributors:

  • Agents: Research, indexing/referencing other project (using standard VS Code GitHub Copilot/MCP tools), planning, and general implementation agents likely already exist. Review .github/agents/ before creating new ones.
  • Skills: Must include both bash and PowerShell scripts. See Skills.
  • Model Versions: Only contributions targeting the latest Anthropic and OpenAI models will be accepted. Older model versions (e.g., GPT-3.5, Claude 3) will be rejected.
  • See Agents Not Accepted and Model Version Requirements.

Other:

  • Script/automation (.ps1, .sh, .py)
  • Other (please describe):

Sample Prompts (for AI Artifact Contributions)

The only AI artifact touched is .github/instructions/workflows.instructions.md, which is passive guidance applied by glob rather than an invocable artifact. It gains the per-job permissions requirement and the four-state classification table so the documented rule matches the enforced one.

User Request:
Not directly invocable. The instruction file applies automatically when editing files under .github/workflows/.

Execution Flow:
An agent editing a workflow file receives the permissions convention as context, including the requirement that every job declare its own permissions: block whenever the workflow-level block grants any scope, and the permissions: {} exception.

Output Artifacts:
None. The file contributes guidance only.

Success Indicators:
A workflow authored under this guidance passes npm run lint:permissions without a MissingJobPermissions finding.

For detailed contribution requirements, see:

Testing

Automated validation

Command Result
npm run lint:permissions (pre-remediation) Failed by design ΓÇö exactly one violation: beval.yml job evaluate, exit 1
npm run lint:permissions (post-remediation) Passed ΓÇö 0 violations, 257/257
npm run test:ps -- -TestPath "scripts/tests/security/" Passed ΓÇö 602 tests, 0 failures
Workflow permissions suite incl. Integration Passed ΓÇö 44 tests, 0 failures, 0 NotRun
npm run lint:ps Passed ΓÇö no PSScriptAnalyzer findings
npm run lint:yaml Passed ΓÇö 65 workflow files
npm run lint:md Passed ΓÇö 0 errors across 531 files
npm run validate:local Passed ΓÇö exit 0
npm run validate:docs Not run ΓÇö fails locally at docs:lint because docs/docusaurus dependencies are not installed here. Unrelated to this change; no Docusaurus files are touched.

The pre-remediation failure is deliberate evidence: it demonstrates the gate has a reachable failure state, which the original top-level-only rule did not.

Test coverage added

A case per matrix row; null, flow-style, and scalar permissions value shapes; four-space job indentation; a lock-file-shaped regression asserting row 2 passes; a nested step-level permissions key that must not count as a job declaration; SARIF ruleId resolution and startLine floor; malformed-workflow degradation; a no-backslash assertion on violation paths; a job-line-attribution regression using a workflow_call output that shares a job name; and a metrics case pinning that a job passing by inheritance is not counted as declaring a block.

The existing workflow-with-permissions.yml fixture was itself in the row-3 state. Rather than relax its passing assertion, the fixture was brought into compliance so the assertion stays meaningful. No existing fixture or assertion was deleted.

Security analysis

  • No secrets, tokens, credentials, or personal data in the diff.
  • No privilege escalation. The single workflow edit is provably privilege-neutral by diff inspection.
  • No new dependency. PowerShell-Yaml 0.4.7 is already pinned in scripts/security/ps-module-versions.json and installed via the cached setup-ps-modules composite action.
  • No generated lock file edited; no scanner suppression added.

Manual testing

Manual testing was not performed. All verification was automated, plus diff-level inspection of the privilege-neutrality claim.

Checklist

Required Checks

  • Documentation is updated (if applicable)
  • Files follow existing naming conventions
  • Changes are backwards compatible (if applicable)
  • Tests added for new functionality (if applicable)

AI Artifact Contributions

  • Used hve-builder review mode to review contribution
  • Addressed all actionable findings from the hve-builder review
  • Verified contribution follows common standards and type-specific requirements

Required Local Checks

The following local-safe validation commands must pass before merging:

  • Local validation aggregate: npm run validate:local
  • Documentation validation (if docs changed): npm run validate:docs (N/A ΓÇö fails locally at docs:lint because docs/docusaurus dependencies are not installed in this environment; unrelated to this change, which touches no Docusaurus files. See Pin eslint to 9.39.4 in docs/docusaurus and regenerate lockfile (PR #2519) #2534.)
  • Spell checking: npm run spell-check
  • Link validation: npm run lint:md-links

Security Considerations

  • This PR does not contain any sensitive or NDA information
  • Any new dependencies have been reviewed for security issues
  • Security-related scripts follow the principle of least privilege

Additional Notes

An independent review pass caught a CI-breaking defect

A subagent review of the diff found that workflow-permissions-scan.yml had no PowerShell-Yaml install step. The original script was pure regex with no module dependency; the new one hard-requires the module via both #Requires and Import-Module -ErrorAction Stop. That workflow runs on ubuntu-latest with only a checkout step, so the gate would have failed on every PR ΓÇö and because both upload steps are continue-on-error: true, it could have failed quietly. The same review found the job metric was crediting jobs that declare no block. Both are fixed here, with the metric split into two honest numbers.

Known behavior: unparseable workflows fail closed

A workflow that fails to parse now emits a MissingPermissions violation and error annotation, and -FailOnViolation exits non-zero. This matches the current implementation and tests (including the unparseable-workflow integration test asserting exit code 1).

lint:yaml (actionlint) remains a complementary guardrail in the PR gate, so malformed workflows are covered both by YAML validation and by the permissions gate itself.

Backwards compatibility

The MissingJobPermissions ValidateSet addition is purely additive. compliance-score and violation-count keep their shape and type; only the score denominator changes, which is called out above for anyone tracking the number over time.

…ons gate

Test-WorkflowPermissions.ps1 matched only `(?m)^permissions:` and scored the
repository 100% while jobs across six workflows carried no permissions block of
their own. The gate did not enforce the job-level convention it documented.

Parse each workflow with ConvertFrom-Yaml, enumerate jobs from the parsed object
graph, and apply a four-state classification:

  absent workflow + absent job    -> violation (inherits repo/org default)
  empty workflow  + absent job    -> pass (zero scopes available to inherit)
  populated workflow + absent job -> violation (implicit, unauditable grant)
  any workflow + present job      -> pass

The empty-block pass is the distinction third-party scanners miss; it is why
alert #348 against pr-review.lock.yml was a false positive.

Remediate the single offender: beval.yml job `evaluate` now declares
`contents: read`, which it already inherited. Effective permissions are
unchanged; no job's privilege was widened and no generated lock file was edited.

Supporting changes:
- Add MissingJobPermissions to the ViolationType ValidateSet, additively
- Report file-level and job-level metrics separately so compliance-score and
  violation-count cannot contradict each other
- Declare a SARIF rule per violation type, floor startLine at 1, and normalize
  paths to forward slashes
- Correct enforcement claims in workflows.instructions.md, security-model.md,
  and scripts/security/README.md to describe the rule actually enforced

Tests cover every matrix row, null/flow/scalar permissions shapes, four-space
job indentation, a lock-file-shaped regression, nested step-level permissions
keys, SARIF contract validity, and malformed-workflow degradation. The
workflow-with-permissions fixture was in the violating state and gained an
explicit job block rather than having its assertion relaxed.
…ibution

Implements review findings RV-001, RV-002, and RV-003 from the job-level
workflow permissions gate review.

RV-001: The documentation added alongside the gate asserted that under a
workflow-level `permissions: {}` a job "can hold no scope regardless of whether
it declares a block". That is false. GitHub adjusts permissions first at the
workflow level and then at the job level, and a job-level block configures a
different set for that job. An empty workflow-level block is a default, not a
ceiling, so a job declaring `contents: write` beneath it does receive that
scope. All affected surfaces now state the accurate reason: a job that declares
no block inherits an empty set and therefore holds nothing.

The gate's four-state verdicts are unchanged; only the stated rationale was
wrong.

RV-002: Add a regression guard asserting no violation path contains a backslash.
The forward-slash normalization had no test, and because the SARIF upload step
is continue-on-error, an unnormalized artifactLocation.uri would fail silently.

RV-003: Constrain job line attribution to the region after the top-level `jobs:`
key. The previous whole-file search could report a same-named key declared
earlier, such as a workflow_call output sharing a job name. Reporting precision
only; job identity has always come from the parsed object graph.
…it job metrics

Addresses findings from an independent review pass over the branch diff.

workflow-permissions-scan.yml had no PowerShell-Yaml install step. The gate
previously used a regex and had no module dependency; it now hard-requires
PowerShell-Yaml 0.4.7 via #Requires and Import-Module -ErrorAction Stop. That
workflow runs on ubuntu-latest with only a checkout step, so the gate would have
failed on every PR. Both upload steps are continue-on-error, so the failure
could have been quiet. Adds the setup-ps-modules composite action, matching the
pattern pr-validation.yml already uses for the same reason.

JobsWithPermissions credited jobs that declare no block. Under an empty
workflow-level block the check returns early, so the job-level violation count
is always zero and every job was counted as declaring permissions. The gate
reported "193/193 declare their own permissions" while pr-review.lock.yml job
pre_activation has no block at all. Split into two honest metrics:

  JobsPassing            193  jobs that pass the job-level check
  JobsDeclaringOwnBlock  188  jobs that actually declare a block

The remaining five pass by inheriting an empty workflow-level block. Log lines
and step-summary rows now distinguish the two, and a test pins the difference.

Also corrects a fixture header comment that repeated the inheritance claim
fixed in the previous commit, and reworks success messages that said jobs
"declare permissions" when some pass by inheritance.
@WilliamBerryiii
WilliamBerryiii requested a review from a team as a code owner July 29, 2026 16:02
@codecov-commenter

codecov-commenter commented Jul 29, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 54.67980% with 92 lines in your changes missing coverage. Please review.
✅ Project coverage is 82.53%. Comparing base (8e4b6c7) to head (f1cf1c0).

Files with missing lines Patch % Lines
scripts/security/Test-WorkflowPermissions.ps1 54.67% 92 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #2553      +/-   ##
==========================================
- Coverage   82.75%   82.53%   -0.23%     
==========================================
  Files         155      155              
  Lines       20963    21123     +160     
  Branches       13       13              
==========================================
+ Hits        17348    17433      +85     
- Misses       3613     3688      +75     
  Partials        2        2              
Flag Coverage Δ
docusaurus 94.44% <ø> (ø)
pester 85.62% <54.67%> (-0.59%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
scripts/security/Modules/SecurityClasses.psm1 97.22% <ø> (ø)
scripts/security/Test-WorkflowPermissions.ps1 46.46% <54.67%> (+8.50%) ⬆️

... and 1 file with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@github-actions

Copy link
Copy Markdown
Contributor

Eval Execution

Status: Passed

  • Artifacts evaluated: 0
  • Specs run: 0
  • Assertions passed: 0
  • Assertions failed (blocking): 0
  • Assertions failed (advisory): 0
  • Failed specs (merge-blocking): 0

No changed AI artifacts required evaluation.

@jkim323 jkim323 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left a couple of things to be looked at!

Comment thread scripts/security/Test-WorkflowPermissions.ps1
Comment thread scripts/security/Test-WorkflowPermissions.ps1 Outdated
WilliamBerryiii and others added 3 commits July 30, 2026 18:16
- treat YAML parse failures as violations under -FailOnViolation
- constrain job declaration line lookup to direct jobs children

✅ - Generated by Copilot
@WilliamBerryiii
WilliamBerryiii requested a review from jkim323 July 31, 2026 04:47

@katriendg katriendg left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Working nicely! Ready to merge.

Wanted to share what my reviewer called out:

The PR description's "Known behavior: unparseable workflows do not block" section states:

"...it produces no violation, so -FailOnViolation does not trip on it."

I tested this directly against a malformed workflow fixture: the script does emit a MissingPermissions violation for parse failures and does trip -FailOnViolation (confirmed via $LASTEXITCODE and the PR's own test Should report an unparseable workflow without counting it as compliant, which asserts $exitCode | Should -Be 1 and passes).

This is a fail-closed behavior, not fail-open as the description claims. It's the safer of the two postures and consistent with the code and tests, so it's not a functional defect — but the description narrative appears to predate the branch's last commit (cd04267 feat(scripts): fail closed on parse errors and tighten job line matching), which changed this behavior without the PR body being updated to match. Worth a comment asking the author to correct/remove that paragraph before merge so reviewers aren't misled about the actual fail-closed guarantee.

@WilliamBerryiii

WilliamBerryiii commented Jul 31, 2026

Copy link
Copy Markdown
Member Author

Thanks for flagging this — fixed. I updated the PR description to remove the stale fail-open wording and replaced it with the current fail-closed behavior

  • parse failures emit a MissingPermissions violation
    -FailOnViolation exits non-zero
  • unparseable-workflow integration test assertion ($exitCode | Should -Be 1)

is called out\n\nThe updated section is now under Known behavior: unparseable workflows fail closed in the PR body.

@WilliamBerryiii
WilliamBerryiii merged commit 207ced2 into main Jul 31, 2026
97 checks passed
@github-actions github-actions Bot mentioned this pull request Jul 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(scripts): enforce job-level permissions in the workflow permissions gate

4 participants